
這個系列從 day 03 到現在累積了很多的 testApplication { } 相關的測試,這篇把這個一直被當成固定寫法的東西拆開,看 Ktor 的原始碼,再一項一項實際量過
testApplication 建出來的到底是什麼,跟 EngineMain 起的差在哪一層configure() 拿到的是一份空設定,跟 3.5.2 這個多載的 KDoc 說法不同configure 的 overrides 為什麼蓋得過 application.yaml
developmentMode 預設是開的,day 17 那個發現的出處client 是接在哪裡的 HttpClient,為什麼測試裡一直手動組 JSON 字串externalServices 假的外部服務,還有它一起繼承走的東西TestApplicationBuilder 裡面這段就是全部的答案,出處是 ktor-server-test-host 的 TestApplication.kt
internal val properties by lazy {
built = true
val environment = createTestEnvironment {
val oldConfig = config
this@TestApplicationBuilder.environmentBuilder(this)
if (config == oldConfig) { // the user did not set config. load the default one
config = MapApplicationConfig()
}
}
serverConfig(environment) {
applicationModules.forEach { module(it) }
parentCoroutineContext += job
watchPaths = emptyList()
developmentMode = true
this@TestApplicationBuilder.applicationProperties(this)
}
}
internal val embeddedServer by lazy {
EmbeddedServer(properties, TestEngine, engineConfig)
}
embeddedServer(...) 那一行 day 04 拆過,EngineMain 是 day 17 換上去的,2 條路最後都走到 EmbeddedServer 這個型別 (day 02 那個煙霧測試點過它的名字),走到這一層時,最關鍵的替換是第 2 個參數,正式跑的是 Netty,測試跑的是 TestEngine,module、plugin 與 routing 可以共用,但 engine 的啟動方式、網路邊界與外部資源仍要另外驗證
TestEngine.create 生的是 TestApplicationEngine,它繼承 BaseApplicationEngine
public class TestApplicationEngine(
...
) : BaseApplicationEngine(environment, monitor, developmentMode, EnginePipeline(developmentMode)), CoroutineScope {
第 4 個參數是 engine pipeline,BaseApplicationEngine 的預設值是 defaultEnginePipeline(environment.config, developmentMode),Netty 拿的是那個,TestEngine 自己傳了一個空白的 EnginePipeline(developmentMode) 進去,然後在 init 裡自己 intercept
這一行取代很小,後果不小,後面講例外那節就是它造成的
testApplication { } 的入口長這樣
val builder = ApplicationTestBuilder()
with(builder) {
...
withContext(parentCoroutineContext) { block() }
}
val testApplication = builder.testApplication
testApplication.start()
testApplication.stop()
block() 是我們寫的那一整段測試,它跑完之後才輪到 start(),所以在 testApplication { } 裡寫的 application { }、routing { }、install() 全部只是登記,applicationModules 這個 list 加一筆而已
真正把它推起來的是 client,DelegatingTestClientEngine.execute 第 1 行就是 config.testApplicationProvder().start(),而 properties、embeddedServer、server 一路都是 by lazy,第 1 個請求才會把整串建出來
建立新的 src/test/kotlin/com/cashwu/todo/TestApplicationTest.kt 第 1 個測試就來驗證這件事
@Test
fun `the modules do not run until the first request`() = testApplication {
val ran = mutableListOf<String>()
application { ran += "module" }
routing { get("/") { call.respondText("ok") } }
assertEquals(emptyList(), ran)
client.get("/")
assertEquals(listOf("module"), ran)
}
不想送請求也可以,startApplication() 會直接叫 testApplication.start(),ran 一樣會變成 ["module"]
@Test
fun `startApplication builds it without a request`() = testApplication {
val ran = mutableListOf<String>()
application { ran += "module" }
assertEquals(emptyList(), ran)
startApplication()
assertEquals(listOf("module"), ran)
}
倒過來的那一面是設定有截止時間,properties 的第 1 行是 built = true,之後每一個設定函式都會先 checkNotBuilt(),先送一次請求再呼叫 application { },接到的是 IllegalStateException
@Test
fun `configuring after the first request is rejected`() = testApplication {
routing { get("/") { call.respondText("ok") } }
client.get("/")
val error = assertFailsWith<IllegalStateException> {
application { }
}
assertEquals(
"The test application has already been built. Make sure you configure " +
"the application before accessing the client for the first time.",
error.message,
)
}
訊息是這一句
The test application has already been built. Make sure you configure the application before accessing the client for the first time.
答案在開頭那段 properties 的前半,也就是 createTestEnvironment { } 那 5 行,createTestEnvironment 自己會先塞一個設定進去,出處是 TestEngine.kt
public fun createTestEnvironment(
configure: ApplicationEnvironmentBuilder.() -> Unit = {}
): ApplicationEnvironment =
applicationEnvironment {
config = MapApplicationConfig("ktor.deployment.environment" to "test")
log = KtorSimpleLogger("io.ktor.test")
configure()
}
所以 oldConfig 是那個帶著一組 key 的 map,沒有呼叫 configure() 或 environment { } 的話,environmentBuilder 是空的 lambda,config 沒被換過,判斷成立,然後 config = MapApplicationConfig() 把它換成一個完全空的 map,註解寫的是「load the default one」,做的事是清空
@Test
fun `a testApplication without configure gets an empty config`() = testApplication {
routing { get("/") { call.respondText("ok") } }
client.get("/")
val config = application.environment.config
assertEquals(emptySet(), config.toMap().keys)
assertNull(config.propertyOrNull("todo.requestIdHeader"))
assertEquals(HttpStatusCode.NotFound, client.get("/todos").status)
}
todo.requestIdHeader 是 application.yaml 裡確實存在的 key,這裡讀不到,/todos 拿到 404,因為 ktor.application.modules 也沒進來,todo 的 module 根本沒跑,連 createTestEnvironment 自己塞的那個 ktor.deployment.environment 都不見了
這個結果跟 testApplication(parentCoroutineContext, block) 這個多載的 KDoc 對不上,那上面有這麼一句,「Note: If you have the application.conf file in the resources folder, [testApplication] loads all modules and properties specified in the configuration file automatically.」 (原文那個開頭的底線沒有閉合,這裡拿掉了),換成 KDoc 講的 application.conf 結果一樣,因為上面那行清空是無條件的
這裡要把兩份官方資料分開看,Ktor 3 migration guide 已經寫明,TestApplication 不再自動載入設定檔裡宣告的 module,要自行呼叫 configure() 或明確載入設定,對不上的只有這個多載在 3.5.2 的 KDoc,不是現行 migration 文件
day 03 講這個踩點的時候寫,「原因是我們的專案走 embeddedServer 風格,沒有 application.yaml 設定檔,testApplication 沒有設定檔可以自動載入 module,所以要自己掛,之後專案有了設定檔,情況會不一樣,那是 day 17 的主題⋯」,Ktor 3 已經拿掉自動載入 module 的行為,有設定檔也得自己呼叫 configure(),day 17 把測試從 application { module() } 全面換成 configure() 的時候實際上就是在做這件事,只是那篇沒有從這個角度講
configure 的本體很短
val fileConfigs = ConfigLoader.loadAll(*configPaths)
val overrideEntries = mutableMapOf<String, String>()
.apply(overrides)
.entries.map { it.toPair() }
val mapConfig = MapApplicationConfig(overrideEntries)
config = fileConfigs.mergeWith(mapConfig)
configPaths 一個都不傳的話 ConfigLoader.loadAll() 會走 load(null),也就是找 classpath 上的預設設定檔,todo-api 的 application.yaml 就是這樣被讀進來的
優先順序在 mergeWith 這裡決定,程式碼讀起來會反直覺,KDoc 倒是寫清楚了,出處是 ktor-server-core 的 MergedApplicationConfig.kt
/**
* Merge configuration combining all their keys.
* If the key exists in this and [other] config, the value from the [other] config will be used.
*/
public fun ApplicationConfig.mergeWith(other: ApplicationConfig): ApplicationConfig =
when {
keys().isEmpty() -> other
other.keys().isEmpty() -> this
else -> MergedApplicationConfig(other, this)
}
MergedApplicationConfig 查 key 的時候 first 優先,而 a.mergeWith(b) 建出來的是 MergedApplicationConfig(b, a),參數被換了位置,所以贏的是 other,也就是 mapConfig,也就是我們寫在 overrides 裡的那些值,名字讀起來像是 this 贏,實際上相反
用 configure(overrides = h2Database) 之後對 3 種來源各斷言一次
@Test
fun `configure merges the yaml underneath the overrides`() = testApplication {
configure(overrides = h2Database)
startApplication()
val config = application.environment.config
assertEquals("X-Request-Id", config.property("todo.requestIdHeader").getString())
assertEquals(
listOf("com.cashwu.todo.ApplicationKt.module"),
config.property("ktor.application.modules").getList(),
)
assertEquals("jdbc:h2:mem:todo", config.property("todo.database.url").getString())
}
todo.requestIdHeader 拿到 yaml 的 X-Request-Id,ktor.application.modules 拿到 yaml 的 [com.cashwu.todo.ApplicationKt.module],而兩邊都有的 todo.database.url 拿到 override 的 jdbc:h2:mem:todo,day 23 那個 h2Database 能把測試留在 H2 上、day 18 那個 put("ktor.application.modules.size", "0") 能關掉 module,靠的都是同一個順序
回頭看開頭那段 properties,serverConfig(environment) { } 裡有一行 developmentMode = true 寫死在那,而下一行的 this@TestApplicationBuilder.applicationProperties(this) 就是我們自己寫的 serverConfig { },它排在後面,所以蓋得掉,day 17 那個「改完之後有一個測試失敗了,失敗的原因是 testApplication 從第 1 天起就一直跑在 development mode 底下,只是以前沒有任何東西會因此改變行為」,來源就是那個 block 裡的先後順序
TestApp.kt 的 todoApplication() 最後一行 serverConfig { this.developmentMode = developmentMode } 把預設值鎖回 false,讓那些 testApplication { } 區塊跑在跟正式環境一樣的模式下,2 個新測試把兩邊都確認一次
@Test
fun `a bare testApplication runs in development mode`() = testApplication {
routing { get("/") { call.respondText("ok") } }
client.get("/")
assertEquals(true, application.developmentMode)
}
@Test
fun `serverConfig turns development mode off`() = testApplication {
serverConfig { developmentMode = false }
routing { get("/") { call.respondText("ok") } }
client.get("/")
assertEquals(false, application.developmentMode)
}
ApplicationTestBuilder.createClient 很短
override fun createClient(
block: HttpClientConfig<out HttpClientEngineConfig>.() -> Unit
): HttpClient = HttpClient(DelegatingTestClientEngine) {
engine {
parentJob = this@ApplicationTestBuilder.job
testApplicationProvder = this@ApplicationTestBuilder::testApplication
}
block()
}
client 這個屬性就是 createClient { },第 1 次讀取的時候建,之後快取,它是真的 HttpClient,get、post、header、setBody 全部是 client 那一套 API,換成別的 engine 就能打真的網路
差別只在 DelegatingTestClientEngine,它不開 socket,把 request 交給 TestApplicationEngine.handleRequest 直接送進 pipeline
沒有 socket 也就沒有真的 host 跟 port,所以要有一組假的,TestApplicationEngine.resolvedConnectors() 在沒設定 connector 的時候回 2 個寫死的 EngineConnectorConfig,localhost:80 走 HTTP、localhost:443 走 HTTPS,所以 client.get("/todos") 這種相對路徑補完之後是 http://localhost/todos
打一個不在清單上的 authority 會拿到 InvalidTestRequestException
@Test
fun `a request to an unknown authority is rejected`() = testApplication {
routing { get("/") { call.respondText("ok") } }
val error = assertFailsWith<InvalidTestRequestException> {
client.get("https://api.example.com/rate")
}
assertEquals(
"Can not resolve request to https://api.example.com. " +
"Main app runs at localhost:80, localhost:443 and external services are ",
error.message,
)
}
訊息長這樣,尾巴那個空的地方是 external services 的清單
Can not resolve request to https://api.example.com. Main app runs at localhost:80, localhost:443 and external services are
更影響日常寫法的是另一件事,createClient { } 傳的是空的 block(),沒有裝任何 plugin,所以預設 client 沒有 ContentNegotiation
@Test
fun `the default client cannot decode a typed body`() = testApplication {
todoApplication()
val response = client.get("/todos")
assertEquals(HttpStatusCode.OK, response.status)
assertFailsWith<NoTransformationFoundException> { response.body<List<Todo>>() }
}
對 /todos 拿到 200,但 response.body<List<Todo>>() 丟出 io.ktor.client.call.NoTransformationFoundException
Expected response body of the type 'class kotlin.collections.List' but was 'class io.ktor.utils.io.SourceByteReadChannel'
In response from `http://localhost/todos`
Response status `200 OK`
Response header `ContentType: application/json`
Request header `Accept: */*`
You can read how to resolve NoTransformationFoundException at FAQ:
https://ktor.io/docs/faq.html#no-transformation-found-exception
這就是為什麼 day 12 到 day 23 的測試裡到處是 Json.decodeFromString<List<Todo>>(response.bodyAsText()) 跟手寫的 setBody("""{"title":"倒垃圾"}"""),要換成型別化的寫法得先補一個相依,build.gradle.kts 的 dependencies 區塊加一行
testImplementation(ktorLibs.client.contentNegotiation)
ktor-client-content-negotiation 跟正式程式碼裝的 ktor-server-content-negotiation 是 2 個不同的 artifact,server 那個裝再多次也不會讓 client 認得 JSON,有了它之後
@Test
fun `a client with ContentNegotiation decodes a typed body`() = testApplication {
todoApplication()
val jsonClient = createClient { install(ContentNegotiation) { json() } }
assertEquals(3, jsonClient.get("/todos").body<List<Todo>>().size)
val created = jsonClient.post("/todos") {
contentType(ContentType.Application.Json)
setBody(CreateTodoRequest("倒垃圾"))
}
assertEquals(HttpStatusCode.Created, created.status)
assertEquals("倒垃圾", created.body<Todo>().title)
}
那個 contentType(...) 不能省,client 的 ContentNegotiation 是照 request 上的 Content-Type 決定要用哪個 converter,沒寫的話會丟 Fail to prepare request body for sending.,訊息裡的 with Content-Type: null. 就是原因
同一個 createClient 也拿來改別的行為,預設 client 會跟著 redirect 走,想看到 302 本人得自己關掉,2 個測試各盯一邊
@Test
fun `the default client follows redirects`() = testApplication {
routing {
get("/old") { call.respondRedirect("/new") }
get("/new") { call.respondText("new") }
}
val response = client.get("/old")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("new", response.bodyAsText())
}
@Test
fun `a client without followRedirects sees the redirect itself`() = testApplication {
routing {
get("/old") { call.respondRedirect("/new") }
get("/new") { call.respondText("new") }
}
val staying = createClient { followRedirects = false }
val response = staying.get("/old")
assertEquals(HttpStatusCode.Found, response.status)
assertEquals("/new", response.headers[HttpHeaders.Location])
}
createClient 建出來的每一個 client 接的都是同一個 app,所以一個測試裡可以同時有型別化的 client 跟原始的 client,各驗各的
手動組 JSON 字串是權宜,只是它的代價一直沒有被算進來,TodoRoutesTest 裡那 3 行一整串的期望字串,改一個欄位名要手動改好幾處,而且改錯了測試只會告訴你 2 個字串不一樣,這篇補上 client 的 ContentNegotiation 之後這條路通了,但 23 篇的既有測試不會回頭改,那是為了改而改
TestApplicationEngine 的 init 就是前面說的那個「取代很小、後果不小」
_callInterceptor.value = {
try {
call.application.execute(call)
} catch (cause: Throwable) {
...
handleTestFailure(call, cause)
}
}
handleTestFailure 比 Netty 那邊的 handleFailure 多一個開關
private suspend fun handleTestFailure(call: ApplicationCall, cause: Throwable) {
logError(call, cause)
if (call.response.isSent) return
val throwOnException = environment.config
.propertyOrNull(CONFIG_KEY_THROW_ON_EXCEPTION)
?.getString()?.toBoolean() != false
val statusCode = defaultExceptionStatusCode(cause)
?: if (throwOnException) throw cause else HttpStatusCode.InternalServerError
tryRespondError(call, statusCode, cause.message)
}
CONFIG_KEY_THROW_ON_EXCEPTION 是 "ktor.test.throwOnException",那個 != false 讓它在沒設定的時候是開的,所以一個沒被 defaultExceptionStatusCode 認出來的例外,預設會被原樣丟出去
丟出去之後沒有跑到測試身上,BaseApplicationEngine 的 init 裡有一行 BaseApplicationResponse.setupFallbackResponse(pipeline, environment.log),它在 EnginePipeline.Before 這個更外面的階段等著
application.intercept(EnginePipeline.Before) {
try {
proceed()
} catch (cause: Throwable) {
...
val content = when {
inDevMode -> ExceptionPageContent(call, cause)
message != null -> TextContent(
text = message,
contentType = ContentType.Text.Plain,
status = HttpStatusCode.InternalServerError
)
else -> ERROR_CONTENT
}
response.respondOutgoingContent(content)
}
}
Netty 那邊碰不到這個 fallback,因為 defaultEnginePipeline 在 EnginePipeline.Call 就把例外接完了,這是 day 17 量到「一般 route handler 丟出的例外會先在 Netty 的 EnginePipeline.Call 被處理,碰不到外層的官方 HTML 例外頁」的原因,TestEngine 沒有那一層,例外往外走一格就撞上 fallback
實際跑起來,一個沒裝 StatusPages 的路由丟 IllegalStateException("炸了"),2 種模式各一個結果
@Test
fun `an unhandled exception becomes a 500 instead of reaching the test`() = testApplication {
serverConfig { developmentMode = false }
routing { get("/boom") { throw IllegalStateException("炸了") } }
val response = client.get("/boom")
assertEquals(HttpStatusCode.InternalServerError, response.status)
assertEquals("炸了", response.bodyAsText())
}
client.get("/boom") 這一行沒有包在 assertFailsWith 裡,因為那個 IllegalStateException 根本沒有跑出來,拿到的是一個 text/plain 的 500 加上例外訊息本身,把 serverConfig 那行拿掉,也就是留在 testApplication 預設的 development 模式,同一個路由拿到的 body 變成 HTML
@Test
fun `in development mode the same exception becomes the html error page`() = testApplication {
routing { get("/boom") { throw IllegalStateException("炸了") } }
val response = client.get("/boom")
assertEquals(HttpStatusCode.InternalServerError, response.status)
assertTrue(response.bodyAsText().startsWith("<html><body><h1>Internal Server Error</h1>"))
}
開頭 2 行是這樣
<html><body><h1>Internal Server Error</h1><h2>Request Information:</h2><pre>Method: GET
From origin: TestConnectionPoint(uri=/boom, method=GET, version=HTTP/1.1, localAddress=localhost, localPort=80, remoteAddress=localhost, remotePort=0)
那個 HTML 例外頁在正式環境是看不到的,因為 Netty 走的是另一條路,它會在測試裡冒出來,純粹是 testApplication 預設把 development mode 打開
defaultExceptionStatusCode 認得的那幾種連 fallback 都不用走,沒有裝 StatusPages,路由丟 NotFoundException 拿到的就是 404
@Test
fun `NotFoundException is already a 404 without StatusPages`() = testApplication {
serverConfig { developmentMode = false }
routing { get("/missing") { throw NotFoundException("沒有這筆") } }
assertEquals(HttpStatusCode.NotFound, client.get("/missing").status)
}
BadRequestException 對 400、UnsupportedMediaTypeException 對 415、PayloadTooLargeException 對 413,這張表在 DefaultEnginePipeline.kt 裡,day 15 裝 StatusPages 之前那幾個測試能拿到合理的狀態碼,靠的就是它
上面 2 節有一個共同的形狀,都是「Netty 那邊會怎樣」的斷言,而證據來源是原始碼,defaultEnginePipeline 在 EnginePipeline.Call 就把例外接完、那個 HTML 例外頁在正式環境看不到,這 2 句都是讀出來的,這篇一次都沒有真的起過 Netty
這裡要標出來,因為讀原始碼推結論跟跑一次拿到結果,可信度不一樣,原始碼可能有另一條分支沒注意到,某個 plugin 可能在中間插一腳,或者某個預設值跟以為的不同,前面 23 篇踩過的坑裡,有好幾個都是這樣來的
而且這件事的範圍比例外大,TestEngine 取代掉的是整個 engine,所以只要是 engine 那一層負責的事情,這個系列到目前為止都沒有測過,例如
EngineMain 配 application.yaml 這條正式啟動路徑,day 17 換上去之後,測試一直是繞過它的testApplication 測不到這些不是缺陷,那本來就不是它的工作,它換掉 engine 換來的是快跟穩,要補的是另一種測試,真的起一台 server、綁一個真的 port、從外面打進去
externalServices { hosts(...) } 是給假的外部服務用的,登記一個 authority,同一個 client 打那個 authority 就會被導到另一個 TestApplication
@Test
fun `externalServices answers another host`() = testApplication {
routing { get("/") { call.respondText("main") } }
externalServices {
hosts("https://api.example.com") {
routing { get("/rate") { call.respondText("""{"rate":31.5}""") } }
}
}
assertEquals("""{"rate":31.5}""", client.get("https://api.example.com/rate").bodyAsText())
assertEquals("main", client.get("/").bodyAsText())
}
登記 https://api.example.com,打 /rate 拿到假服務的回應、打 / 拿到主 app 的回應,兩邊互不干擾
有一個地方要注意,那個假的服務不是從乾淨的環境長出來的
externalApplicationBuilders[protocolWithAuthority] = {
TestApplication {
environment(this@ExternalServicesBuilder.testApplicationBuilder.environmentBuilder)
applicationModules.add(block)
}
}
它把主 app 的 environmentBuilder 整份接過去,而 configure() 做的事就是往 environmentBuilder 上疊一層,所以主 app 一旦呼叫過 configure(),那份設定裡的 ktor.application.modules 也會跟著進到假的服務裡
@Test
fun `an external service inherits the configured modules`() = testApplication {
configure(overrides = h2Database)
externalServices {
hosts("https://api.example.com") {
routing { get("/rate") { call.respondText("""{"rate":31.5}""") } }
}
}
assertEquals("""{"rate":31.5}""", client.get("https://api.example.com/rate").bodyAsText())
assertEquals(HttpStatusCode.OK, client.get("https://api.example.com/todos").status)
}
第 2 個斷言就是重點,https://api.example.com/todos 回 200,因為那個假的外部服務把整個 todo API 也載進去了,要模擬「外部服務回了一個奇怪的東西」的時候這會是雜訊,照 day 18 那條路應該可以解,put("ktor.application.modules.size", "0") 把 module 清單清掉再自己掛需要的,不過這篇沒有實測到那一步
todo-api 現在沒有呼叫任何外部服務,所以這個東西暫時只是備著,day 31 用 MockEngine 測 HttpClient 的時候會再回來比一次,2 個工具解的是同一個問題的兩端
day 18 到 day 19 摸出來的那條路,現在的完整樣子在 TestApp.kt 的 todoApplication() 裡,clock 跟 repository 2 個參數只要有一個不是 null,就走這個分支
configure(overrides = {
h2Database()
put("ktor.application.modules.size", "0")
})
application {
if (clock != null) dependencies.provide<Clock> { clock }
if (repository != null) dependencies.provide<TodoRepository> { repository }
}
application { module() }
3 個動作固定成一組,關掉設定檔的 module,先掛一個只放假相依的 module,再自己掛 module(),順序不能反,day 18 查過原因,DI plugin 偵測到 engine 是 TestApplicationEngine 的時候會換成 IgnoreConflicts,衝突時一律 KeepPrevious,原始碼的註解是「During testing, we ignore conflicts by providing dependencies BEFORE loading modules」,晚註冊的那個永遠會輸
換掉相依之後真實的東西有沒有被建出來,這件事其實一直沒有被證明過,provide 註冊的是 lambda,Application.kt 裡的 provide<DataSource> 跟 provide<Database> 只有在有人 resolve 的時候才會跑,而唯一會 resolve 它們的是 provide<TodoRepository> 那個 lambda,假的 repository 先進去之後,那條鏈整段都不會被碰到
要驗證就給它一個絕對連不上的 URL
@Test
fun `a provided repository keeps the database out of the test`() = testApplication {
configure(overrides = {
h2Database()
put("todo.database.url", "jdbc:nowhere:boom")
put("ktor.application.modules.size", "0")
})
application { dependencies.provide<TodoRepository> { FakeTodoRepository(emptyList()) } }
application { module() }
val created = client.post("/todos") {
contentType(ContentType.Application.Json)
setBody("""{"title":"倒垃圾"}""")
}
assertEquals(HttpStatusCode.Created, created.status)
assertEquals(
"""{"id":1,"title":"倒垃圾","done":false,"created_at":"2026-10-10T12:00:00Z"}""",
created.bodyAsText(),
)
}
h2Database() 已經把 driver 設成 org.h2.Driver,而它不接受這個 URL,todoDataSource 只要被呼叫一次就會丟
java.lang.RuntimeException: Driver org.h2.Driver claims to not accept jdbcUrl, jdbc:nowhere:boom
第 1 次跑這個測試的時候它是失敗的,而且不是斷言不過,是 app 根本起不來
io.ktor.server.plugins.di.DependencyInjectionException: Some dependencies could not be resolved
log 裡指名的是 Cannot resolve org.jetbrains.exposed.v1.jdbc.Database,原因在 module() 自己身上,那裡有一行
val database: Database by dependencies
by dependencies 不是等到有人用才解析的,module 載入的時候 Ktor DI 就會把它解出來,所以 DataSource 跟 Database 一定會被建,而這一行是死的,底下那段用到它的 transaction(database) 在 day 21 之後就註解掉了,宣告留著卻還在把整個資料庫拉起來,拿掉那一行,測試就通過了
這是這一篇唯一動到的正式程式碼,1 行刪除,順帶說明了一件事,by dependencies 這個寫法看起來像 by lazy,但它不是,宣告出來就等於用掉了
回應裡那個 2026-10-10T12:00:00Z 是 FakeTodoRepository 拿 FIXED_NOW 寫的,id 從 1 開始因為種子清單是空的,測試通過,代表整條資料庫的鏈從頭到尾一次都沒有被建出來
不過這 3 個動作是權宜,而且是不得已的權宜,put("ktor.application.modules.size", "0") 靠的是 MapApplicationConfig 存 list 的內部格式,Ktor 沒有承諾過這個 key 的形狀,換一個版本可能就不對了,真正乾淨的做法是框架提供一個「在 module 之前註冊相依」的入口,但 3.5.2 沒有
Relix 也走到過同一個路口,day 28 那篇的開場是「這篇要做一件收斂的事,把散落的測試能力整理成穩定的 TestRelixApplication 與 relixTest { } DSL」,做完之後的入口跟 testApplication 幾乎是同一個形狀,install()、routing {} 直接委派給內部的 app,assertion 寫在 block 裡
有 2 個決定不一樣
第 1 個是例外,Relix 的 day 21 寫,「createApp() 裡面那行 install(ErrorHandling) 不是裝好看的,receive<T>() 打算用 throw 表達錯誤,丟的是第 16 篇那個 RelixHttpException,沒有 middleware 在外面接住的話,exception 會一路往外穿出去,測試拿到的不是 400 也不是 415,而是直接爆掉,錯誤碼要能被斷言,前提是有人負責把 exception 翻成 response」,那個系列的 day 22、day 23、day 24 各重複提醒過一次,可見踩了幾次
Ktor 這邊不會踩到,因為 setupFallbackResponse 在 engine 那一層就把最後一道網張好了,手刻的時候會覺得「例外穿出去也合理,反正測試會失敗」,實際用起來差很多,穿出去的那一刻,你失去的是狀態碼這個可以拿來斷言的東西,這是 engine 這一層該做而容易漏掉的事
第 2 個是 development 模式的預設值,Relix 的 RelixConfig 裡 var development: Boolean = false,day 28 說「需要測 development 模式的行為時才傳進去」,Ktor 反過來,testApplication 硬寫 developmentMode = true,要跟正式環境一致得自己關,兩邊都可以講出理由,但 Relix 那個預設值讓測試預設就跑在正式環境的模式下,day 17 那個「一直跑在 development mode 底下,只是以前沒有任何東西會因此改變行為」的意外不會發生
testApplication 建的是 EmbeddedServer(properties, TestEngine, engineConfig),跟 EngineMain 只差在 engine 那個參數,TestApplicationEngine 傳給 BaseApplicationEngine 的是空白的 EnginePipeline(developmentMode) 而不是 defaultEnginePipeline,這一行取代是後面所有差異的來源
裡面的設定函式全部只是登記,第 1 個 client 請求或 startApplication() 才真的建,之後再改會拿到 The test application has already been built,不呼叫 configure() 的話 config 是完全空的,呼叫了則 overrides 蓋得過 application.yaml,而 developmentMode 預設是 true,要跟正式環境一致得自己關
client 是真的 HttpClient,只是沒裝 ContentNegotiation,所以 body<List<Todo>>() 會丟 NoTransformationFoundException,未處理的例外也不會穿到測試身上,setupFallbackResponse 在 EnginePipeline.Before 就接住了,development 模式回 HTML 例外頁、正式模式回 text/plain 的 500
還有一個沒預期的發現,by dependencies 看起來像 by lazy,但它宣告出來就等於用掉了,module() 裡那行沒人用的 val database: Database by dependencies 一直在把整個資料庫拉起來,刪掉之後才證明得了換掉 repository 真的把資料庫擋在外面
withDatabase 每個測試開一個名字不同的 in-memory H2,隔離做得很乾淨,代價是那不是正式環境會跑的資料庫,day 23 量到的 PostgreSQL 行為裡有 3 個 ./gradlew test 完全碰不到,varchar 數 code point、timestamptz 不留 offset、readOnly 會擋
下一篇用 Testcontainers 在測試裡起一個真的 PostgreSQL 容器,處理容器的生命週期跟每個測試之間的隔離,順便看看 day 23 那 3 個 ./gradlew test 碰不到的行為進得了測試之後長什麼樣,還有 day 23 掛著沒改的那筆 validation 待辦
同步刊登於 Blog
圖片來源:AI 產生